Playwright 浏览器自动化、端到端测试与 Playwright MCP

1. 概述

Playwright(发音 /ˈpleɪˌraɪt/)是微软(Microsoft)开源的一个端到端(E2E, End-to-End)浏览器自动化与测试框架。它通过统一的 API 控制 Chromium、Firefox 和 WebKit 三大浏览器引擎,可用于 UI 自动化测试、爬虫、截图、接口测试与组件测试。

Playwright 解决的核心问题:Web 前端交互复杂、动画与异步请求多,传统测试框架需要大量人工 sleep/wait 导致测试不稳定(flaky)。Playwright 用 自动等待(Auto-waiting) 与 Web-First 断言 让测试代码"简单、可靠、零脆弱"。

Playwright 提供三种使用形态:

形态包用途
Playwright Library(库)playwright手动 launch 浏览器、创建 Context、写脚本,适合爬虫与自动化任务
Playwright Test(测试框架)@playwright/test内置 Test Runner、Fixtures、Web-First 断言、并行、报告等全套 E2E 测试能力
Playwright MCP(MCP 服务器)@playwright/mcp把 Playwright 封装成 MCP 工具,让大模型(Agent)自己驱动浏览器,详见第 6 章

官方支持语言:JavaScript / TypeScript(主力)、Python、Java、.NET。支持桌面与移动端设备模拟,本地或 CI(持续集成)运行,headless(无头)或有头模式。

1.1 核心特性

1.2 Playwright 与网页 JavaScript 的区别

Playwright 是"用脚本从浏览器外部控制浏览器"的自动化工具,而网页 JavaScript 是"在浏览器内部运行的页面脚本"。两者运行位置不同,控制能力也不同:

维度网页 JS(DOM 脚本)Playwright 脚本
运行位置浏览器内部,在页面沙箱中执行浏览器外部(Node.js / Python 等进程),通过 CDP / Juggler 协议遥控浏览器
控制范围只能操作当前已加载页面的 DOM能启动/关闭浏览器、开多个标签页、创建隔离 Context、拦截网络、截图录屏
权限限制受同源策略(Same-Origin Policy)约束无同源限制,可跨站点操作、模拟键盘鼠标、Mock 接口
主要用途实现应用自身的交互逻辑(按钮、表单、动画)端到端测试、爬虫、UI 自动化、批量截图
执行者视角"房间里的人,只能操作这一间房""楼外的总控,用对讲机指挥整栋楼的保安系统"
注意:Playwright 脚本里也可以调用 page.evaluate() 往页面注入 JS——但那只是它遥控浏览器的手段之一,不代表它本身就是网页 JS。网页 JS 只能做应用自己的交互,Playwright 则把它当成被操控的对象。
// 网页 JS:页面内部代码,只能操作当前页面
document.querySelector('#submit').addEventListener('click', () => {
  document.getElementById('result').textContent = 'done';
});

// Playwright 脚本:进程外代码,遥控整个浏览器
const { chromium } = require('playwright');
(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('https://example.com');
  await page.getByRole('button', { name: 'Submit' }).click();  // 真实模拟用户点击
  await page.screenshot({ path: 'after.png' });                 // 浏览器外发起的截图
  await browser.close();
})();

2. 核心概念

理解 Playwright 先理解它的对象模型层级,从大到小为:Playwright → BrowserType → Browser → BrowserContext → Page → Locator。

2.1 BrowserType 与 Browser

BrowserType(浏览器类型)是 chromium、firefox、webkit 三个入口对象,负责 launch() 启动浏览器。启动后得到 Browser(浏览器实例),一个 Browser 可创建多个相互隔离的 BrowserContext。

Browser(浏览器实例):一次浏览器进程。默认以 headless(无头)模式运行;传 headless: false 显示窗口便于调试。

2.2 BrowserContext(浏览器上下文)

BrowserContext 相当于一个"隐身窗口/全新浏览器档案",拥有独立的 localStorage、sessionStorage、cookies、缓存与权限。它是 Playwright 实现测试隔离的核心。每一条测试默认获得一个全新的 Context。

2.3 Page(页面)

Page 代表一个浏览器标签页,提供 goto()、locator()、screenshot() 等操作。一个 Context 可以有多个 Page(可模拟多页签/多用户场景)。

2.4 Locator(定位器)

Locator 是 Playwright 最推荐的元素定位方式,它不直接持有 DOM 元素,而是"定位规则"。在任何时刻解析元素,天然支持重试与动态页面。推荐的定位优先顺序:用户可见性(getByRole)→ 文本(getByText)→ 标签(getByLabel)→ 测试 ID(getByTestId)→ CSS/XPath。

2.5 Fixture(测试夹具)

Fixture 是 Playwright Test 的核心机制:为每条测试惰性初始化环境(如 page、context、browser),测试用完自动清理。可自定义、可组合、可依赖,替代传统 before/after 钩子。

2.6 Auto-waiting 与 Actionability

执行 click()、fill() 等动作前,Playwright 自动等待元素满足"可操作性"(Actionability)检查:可见、稳定(连续两帧不动)、接收事件(不被遮挡)、可用(enabled)、可编辑(editable)。超时则抛出 TimeoutError。

2.7 Web-First Assertions(Web 优先断言)

以 await expect(locator).toBeVisible() 形式书写,断言会自动重试直到条件满足或超时,从而消除脆弱的等待逻辑。

2.8 专有名词速览

术语(英文)解释
E2E Test(端到端测试)在真实浏览器中模拟真实用户操作,验证整个应用链路
Headless(无头模式)浏览器不显示窗口,后台运行,常用于 CI
Flaky Test(不稳定测试)同一份代码时而通过时而失败,多因缺少等待或依赖共享状态
CDP(Chrome DevTools Protocol)Chromium 系浏览器的调试控制协议,Playwright 通过它驱动 Chromium/Chrome/Edge
JugglerPlaywright 自研的 Firefox / WebKit 调试协议(补丁化的专用协议)
Worker(工作进程)Test Runner 并行运行的进程,每个 Worker 顺序执行一批测试
Project(测试项目)config 中定义的一组配置(浏览器、设备、设置),同一测试可在多个 Project 下跑
Sharding(分片)把测试集按机器拆分并行执行,缩短 CI 总时长
Trace(追踪)记录测试期间的页面快照、网络、控制台与操作,供 Trace Viewer 回放
Codegen(代码生成)通过操作真实浏览器自动录制生成测试代码
POM(Page Object Model)页面对象模型,把页面操作封装成类,提升复用与可维护性

3. 行为图(系统"做什么")

3.1 用例图——Playwright 整体能力

展示围绕 Playwright 的各角色(开发者、测试工程师、CI/CD 系统、被测浏览器)与其核心用例。

PlantUML diagram
Playwright 端到端测试用例图

用例解读:开发者负责安装、编写与调试测试;测试工程师可借助 Codegen 录制脚本、用 UI Mode 可视化调试;CI/CD 系统无头批量运行全部项目并产出报告;所有交互最终都由 Playwright 驱动真实浏览器完成。

3.2 活动图——从编写测试到 CI 集成的完整流程

展示一条 E2E 测试从编写、本地运行、调试修复,到推入 CI 跨浏览器执行的端到端工作流(带泳道)。

PlantUML diagram
测试开发与 CI 集成流程活动图

3.3 时序图——单条测试的执行内部流程

展示 Test Runner 运行一条测试时,Fixture 系统、驱动层与浏览器进程的协作细节,重点体现"每次运行都是全新隔离环境"。

PlantUML diagram
运行单条 E2E 测试的时序图

3.4 状态机图——测试用例生命周期

展示一条测试用例在 Test Runner 中的状态流转,包含跳过、执行、通过/失败、超时以及失败重试(flaky)等状态。

PlantUML diagram
测试用例生命周期状态机图

4. 结构图(系统"由什么组成")

4.1 部署图——运行时环境拓扑

展示 Playwright 在开发机与 CI 服务器上的部署形态,以及浏览器二进制、被测应用与测试产物之间的部署关系。

PlantUML diagram
Playwright 部署图(开发机 + CI + 被测应用 + 产物)

部署要点:浏览器二进制默认缓存在 ~/.cache/ms-playwright(Linux)/ ~/Library/Caches/ms-playwright(macOS)/ %USERPROFILE%\AppData\Local\ms-playwright(Windows)。CI 上用 npx playwright install --with-deps 一键安装浏览器及系统依赖。

4.2 组件图——分层组件与协议适配

展示 Test Runner、Library、playwright-core、协议适配层与浏览器进程之间的组件依赖,揭示"统一 API 如何驱动三种浏览器"。

PlantUML diagram
Playwright 架构组件图
架构原理:Node 客户端通过 playwright-core 内的 Driver Server 与浏览器进程通信。对 Chromium 系使用 CDP 协议;对 Firefox / WebKit 使用 Playwright 定制补丁的 Juggler 协议,从而保证三套引擎行为一致性。

4.3 CDP 协议详解

CDP(Chrome DevTools Protocol,Chrome 开发者工具协议)是 Chromium/Chrome 提供的官方调试协议,用于外部进程对浏览器进行"遥控"——从 DevTools 面板到 Puppeteer、Playwright 等自动化工具,底层都走 CDP。它是 Playwright 驱动 Chromium 系浏览器(含 Chrome、Edge)的通信通道。

4.3.1 CDP 的消息模型

CDP 本质是一个 JSON-RPC(远程过程调用)协议:客户端向浏览器发"命令"(Command/Method),浏览器回"响应"(Response);浏览器主动上报"事件"(Event)。消息通过 WebSocket(远程调试端口)或 管道(pipe,本地进程)传输。

消息类型格式说明
命令 Request{ "id": 1, "method": "Page.navigate", "params": { "url": "..." } }客户端请求,id 用于匹配响应
响应 Response{ "id": 1, "result": { "frameId": "..." } } 或 { "id": 1, "error": {...} }浏览器对命令的应答
事件 Event{ "method": "Page.frameNavigated", "params": {...} }浏览器主动推送(如导航、网络、DOM 变化)
PlantUML diagram
CDP 消息交换时序图——以 page.goto() 导航为例

4.3.2 CDP 的领域划分(Domain)

CDP 把浏览器能力按功能划分成若干 Domain(领域),每个 Domain 下有成组的命令与事件,类似命名空间。Playwright 的大部分 API 就是对这些命令的封装。

Domain职责对应 Playwright 能力
Page页面导航、加载状态、截图page.goto()、page.screenshot()
RuntimeJavaScript 求值、控制台page.evaluate()
DOMDOM 查询与操作locator() 底层查询
Input鼠标/键盘/触摸事件locator.click()、press()
Network请求/响应、拦截、模拟page.route()、Mock API
Emulation设备/视口/地理/时区模拟devices['iPhone 12']、geolocation
Target标签页/Target 管理context.newPage()、多标签
Accessibility可访问性树getByRole() 定位
Tracing性能追踪Trace Viewer 数据来源

4.3.3 为什么不直接用 CDP?

4.3.4 如何直接使用 CDP

通过 page.context().newCDPSession(page) 可拿到一个 CDP 会话,直接 send() 命令、监听事件:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  // 建立 CDP 会话
  const session = await page.context().newCDPSession(page);

  // 启用网络域,监听响应事件
  await session.send('Network.enable');
  session.on('Network.responseReceived', ({ response }) => {
    console.log('状态码:', response.status, 'URL:', response.url);
  });

  await page.goto('https://example.com');
  await browser.close();
})();

CDP 专有名词:JSON-RPC(一种基于 JSON 的远程调用协议)、WebSocket(浏览器/服务端全双工通信通道)、Command/Response(命令/响应)、Event(浏览器主动事件)、Domain(协议功能域)、Session(CDP 会话,绑定到特定 Target)、Target(浏览器中的一个可调试目标,如标签页)。

4.4 包图——npm 包与依赖关系

展示 Playwright 相关 npm 包(@playwright/test、playwright、playwright-core、@playwright/browser-*)的组成与依赖。

PlantUML diagram
Playwright npm 包依赖图

包选型:做 E2E 测试用 @playwright/test;只做爬虫/自动化用 playwright。两者底层都依赖 playwright-core。@playwright/browser-chromium 等辅助包会在 npm install 时自动下载对应浏览器。

4.5 类图——核心 API 对象模型

展示 Playwright 编程模型中最核心的类及其关系,也是读者写代码时最常打交道的对象。

PlantUML diagram
Playwright 核心 API 类图

5. 实战速查手册

5.1 安装与初始化

# 初始化新项目(交互式选择 TS/JS、测试目录、是否装浏览器)
npm init playwright@latest

# 在已有项目中安装
npm install -D @playwright/test

# 安装浏览器二进制(chromium / firefox / webkit)及系统依赖
npx playwright install --with-deps

# 安装指定浏览器
npx playwright install webkit

5.2 CLI 常用命令

命令说明
npx playwright test运行全部测试(headless、默认并行)
npx playwright test tests/example.spec.ts运行指定文件
npx playwright test --headed有头模式,显示浏览器窗口
npx playwright test --project=firefox只跑指定浏览器项目
npx playwright test --ui打开 UI Mode 可视化调试
npx playwright test --debug断点调试(配合 Inspector)
npx playwright test --grep "keyword"按标题/标签过滤测试
npx playwright test --shard=1/2分片运行(第 1/2 片)
npx playwright test --last-failed只重跑上次失败的测试
npx playwright codegen url打开浏览器录制生成测试代码
npx playwright show-report打开上一次的 HTML 报告
npx playwright show-trace trace.zip打开 Trace Viewer 回放追踪

5.3 最常用 Locator 写法

page.getByRole('button', { name: 'Submit' })   // 按 ARIA 角色(最推荐,贴近用户视角)
page.getByRole('heading', { name: 'Home' })
page.getByText('Hello World')                   // 按文本
page.getByLabel('User Name')                    // 按表单 label(自带可访问性)
page.getByPlaceholder('请输入邮箱')              // 按 placeholder
page.getByTestId('submit-btn')                  // 按 data-testid(最稳)
page.getByTitle('Close')                        // 按 title 属性
page.locator('input[name="email"]')             // 按 CSS
page.locator('xpath=//button[@id="go"]')        // 按 XPath(最后手段)
page.frameLocator('#iframe').getByRole(...)     // 进入 iframe 内部定位

5.4 最常用 Web-First 断言

await expect(page).toHaveTitle(/Playwright/);                  // 页面标题
await expect(page).toHaveURL('https://example.com');           // 页面 URL
await expect(locator).toBeVisible();                            // 可见
await expect(locator).toBeHidden();                             // 隐藏
await expect(locator).toBeEnabled();                            // 可用
await expect(locator).toBeChecked();                            // 已勾选
await expect(locator).toHaveText('Login');                      // 精确文本
await expect(locator).toContainText('Wel');                     // 包含文本
await expect(locator).toHaveValue('foo');                       // 输入框值
await expect(locator).toHaveCount(3);                           // 元素个数
await expect(locator).toHaveAttribute('href', '/docs');         // 属性
await expect(response).toBeOK();                                // 响应 2xx/3xx

5.5 常用 Action 动作

await page.goto('https://example.com');        // 导航
await locator.click();                          // 单击
await locator.dblclick();                       // 双击
await locator.fill('text');                     // 输入(清空后输入)
await locator.press('Enter');                   // 按键
await locator.check() / uncheck();              // 勾选/取消勾选
await locator.selectOption('optionValue');      // 下拉选择
await locator.setInputFiles('file.png');        // 文件上传
await locator.hover();                          // 悬停
await locator.dragTo(targetLocator);            // 拖拽
await locator.screenshot({ path: 'el.png' });   // 元素截图
await page.screenshot({ path: 'page.png' });    // 整页截图
await page.waitForTimeout(1000);                // 仅调试用,生产代码慎用

5.6 配置文件要点(playwright.config.ts)

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',                          // 测试目录
  timeout: 30_000,                             // 单测超时
  retries: 2,                                  // 失败重试次数(CI 上常用)
  workers: 4,                                  // 并行 Worker 数
  reporter: [['list'], ['html']],              // 终端列表 + HTML 报告
  use: {
    baseURL: 'https://example.com',            // 测试内相对路径的基准
    trace: 'on-first-retry',                   // 首次重试时记录 Trace
    screenshot: 'only-on-failure',             // 失败时截图
    video: 'retain-on-failure',                // 失败时保留录屏
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
    { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
  ],
  webServer: {                                 // 测试前自动启动被测应用
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

5.7 一个完整的测试示例

import { test, expect } from '@playwright/test';

test('登录后可看到欢迎语', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('用户名').fill('brian');
  await page.getByLabel('密码').fill('secret');
  await page.getByRole('button', { name: '登录' }).click();

  await expect(page.getByTestId('welcome')).toContainText('欢迎回来');
  await expect(page).toHaveURL(/\/home/);
});

5.8 Fixture 与 Page Object Model(POM)

把页面封装成类(POM),用 test.extend() 注册为 Fixture,实现复用与隔离:

// todo-page.ts —— 页面对象
export class TodoPage {
  constructor(public readonly page: Page) {}
  async goto() { await this.page.goto('/todos'); }
  async add(text: string) {
    await this.page.getByPlaceholder('What needs to be done?').fill(text);
    await this.page.getByPlaceholder('What needs to be done?').press('Enter');
  }
}

// my-test.ts —— 注册为 Fixture
export const test = base.extend<{ todoPage: TodoPage }>({
  todoPage: async ({ page }, use) => {
    const tp = new TodoPage(page);
    await tp.goto();
    await use(tp);            // use 之后自动清理
  },
});

// todo.spec.ts —— 直接使用
test('添加待办', async ({ todoPage, page }) => {
  await todoPage.add('buy milk');
  await expect(page.getByText('buy milk')).toBeVisible();
});

5.9 调试技巧

场景手段
想逐步观察页面--headed 有头运行;--slow-mo=500 放慢
定位器选不准元素UI Mode 中点元素看高亮;或 page.locator(sel).count() 排查数量
需要断点--debug 打开 Inspector;代码里 await page.pause()
复盘失败过程Trace Viewer 回放 trace.zip,查看每一步快照、网络与控制台
环境变量进入调试PWDEBUG=1 npx playwright test
只跑失败用例--last-failed;或 --grep 关键字过滤

5.10 常见反模式与建议

反模式问题正确做法
waitForTimeout(3000) 硬等待固定时间等待,不稳定依赖自动等待与 Web-First 断言
用 CSS 复杂选择器定位易受重构影响优先 getByRole/getByTestId
测试间共享登录态/数据状态泄漏导致偶发失败每条测试独立 Context;用 storageState 复用登录态
把断言写在 action 后面用 try/catch掩盖真实错误使用 await expect(...) 声明式断言
测试代码重复的页面操作维护成本高抽取 POM + Fixture 复用
大量并发依赖外部真实服务慢且不稳定用 page.route() 拦截 Mock 接口

5.11 Mock 接口示例

test('拦截并返回假数据', async ({ page }) => {
  await page.route('**/api/user', route =>
    route.fulfill({ json: { name: 'Brian', vip: true } })
  );
  await page.goto('/profile');
  await expect(page.getByTestId('user-name')).toHaveText('Brian');
});

5.12 登录态复用(免每次登录)

// 首次:录制登录态
npx playwright codegen --save-storage=auth.json https://example.com/login

// config 中:注入 storageState,测试直接带登录态
export default defineConfig({
  use: {
    storageState: 'auth.json',
  },
});

6. Playwright MCP

一句话理解:Playwright 是「给程序员用的浏览器自动化库」;Playwright MCP 是「把 Playwright 包成一个 MCP 服务器,让大模型(Agent)也能调用」。前者由你写代码驱动,后者由模型自己决定调用哪个工具。

6.1 什么是 MCP

MCP(Model Context Protocol,模型上下文协议)是 Anthropic 于 2024 年 11 月开源的开放协议,用来标准化「大模型应用」与「外部工具 / 数据」之间的连接方式。官方常打一个比方:MCP 之于 AI 应用,就像 USB-C 之于外设——只要实现同一个协议,任何客户端都能插上任何服务器。

它本身与 Playwright 无关,是通用协议;Playwright MCP 只是众多 MCP 服务器中的一个(同类的还有文件系统、GitHub、数据库、Slack 等)。协议里有三个角色:

角色职责例子
Host(宿主)运行大模型、发起会话的应用程序OpenCode、Claude Desktop、VS Code、Cursor
Client(客户端)Host 内部的连接器,一个 Server 对应一个 Client,负责握手与消息收发由 Host 内置,使用者无需实现
Server(服务器)把某项能力通过协议暴露出去的程序@playwright/mcp

协议定义了三种能力原语(primitive):

另有若干可选能力:Sampling(服务器反过来请求模型生成)、Roots(告知服务器可访问的目录范围)、Elicitation(服务器向用户追问信息)。传输层默认用 stdio(本地子进程的标准输入输出),也可用 Streamable HTTP / SSE 走网络;消息格式统一为 JSON-RPC 2.0。会话生命周期大致是:initialize(协商能力)→ tools/list(取工具定义)→ tools/call(反复调用)→ 通知与关闭。

6.2 架构与通信

以本机实际配置为例(OpenCode + Playwright MCP + 一个已经打开的 Chrome):

Playwright MCP 组件与部署图
Playwright MCP 组件与部署图
Playwright MCP 请求时序图
一次「打开网页并点击」的完整请求时序

关键点:

6.3 与 Playwright Library / Test / CLI 的关系

同一个 Playwright 引擎,有四种出口。区别不在于「能不能做」,而在于谁在调用、以及被调用方的「结果」放进哪里:

形态谁在调用交互方式上下文里常驻什么典型场景
Playwright Library程序员写 Node / Python 代码你自己的源码爬虫、批量自动化脚本
Playwright Test程序员 / CInpx playwright test测试报告E2E 回归测试
Playwright MCP大模型(Agent)模型生成工具调用25+ 个工具的 JSON Schema
+ 每一步的完整无障碍树(且旧的一直留着)
探索式自动化、一次性任务、需要边看页面边判断的流程
playwright-cli + Skills编程 AgentAgent 执行 shell 命令一份 Skill 说明
+ 命令输出摘要(快照落盘,不进上下文)
大量重复操作、上下文吃紧的编程 Agent、CI
官方选型建议(大意):「编程 Agent 越来越偏向 CLI + Skills,因为 CLI 调用更省 token——不必把庞大的工具 schema 与冗长的无障碍树塞进上下文;而 MCP 更适合需要持久状态、丰富内省、多轮迭代推理的场景,例如探索式自动化、自愈测试、长时间自治的工作流。」

这句话怎么落地?6.8 拆机制与用法,6.9 用同一个任务做实测对照,6.10 给选型判据。

6.4 核心机制:无障碍快照(accessibility snapshot)与 ref

这是 Playwright MCP 与「截图 + 视觉模型」路线最本质的区别:它不「看」像素,而是读无障碍树。

// browser_snapshot 返回的是这种结构化文本(示意)
- generic [ref=e3]:
  - banner:
    - link "百度首页" [ref=e8]
  - main:
    - textbox "搜索" [ref=e12]
    - button "百度一下" [ref=e14]
三个必须知道的限制:
  1. 快照只覆盖无障碍树中的「语义」元素。用 div 拼出来的假按钮若没有 role / aria-* 属性,在树里可能只是 generic,定位不到——此时要用 browser_find 搜文本,或退到 browser_evaluate 直接查 DOM。
  2. 虚拟滚动列表只包含可见部分(本机实测一屏约 22 行);视口外的行不在快照里,必须先滚动再看。
  3. browser_take_screenshot 只能给人看,不能据此操作。官方描述写得很直白:You can't perform actions based on the screenshot, use browser_snapshot for actions。

6.5 工具清单

当前版本(@playwright/mcp@0.0.83)默认暴露约 25 个工具,其余需用 --caps 显式开启。分组如下:

分组主要工具说明
核心自动化
(默认)
browser_navigate、browser_click、browser_type、browser_hover、browser_select_option、browser_press_key、browser_file_upload、browser_drag、browser_wait_for、browser_navigate_back、browser_close基本交互动作,参数多为 target(ref 或选择器)+ element(人类可读描述,用于请求授权)
观测类browser_snapshot、browser_find、browser_console_messages、browser_network_requestsbrowser_find 只在快照里搜文本 / 正则,比整棵快照省 token
执行代码browser_evaluate、browser_run_code_unsafe在页面里跑 JS / 跑 Playwright 代码,可绕过定位限制
标签页browser_tabslist / new / close / select
窗口与弹窗browser_resize、browser_handle_dialog改变视口、处理 alert / confirm
--caps=storagebrowser_cookie_*、browser_localstorage_*、browser_sessionstorage_*、browser_storage_state读写 Cookie 与存储,可做登录态复用
--caps=networkbrowser_route、browser_unroute、browser_route_list、browser_network_state_set拦截 / Mock 请求
--caps=devtoolsbrowser_highlight、browser_annotate、browser_start_tracing、browser_start_video、browser_resume高亮元素、录屏、录制 trace(调试用)
--caps=visionbrowser_mouse_click_xy、browser_mouse_move_xy、browser_mouse_wheel 等基于坐标的鼠标操作,用于无障碍树覆盖不到的界面(Canvas 等)
--caps=pdf / testingbrowser_pdf_save;browser_verify_*、browser_generate_locator导出 PDF;断言与定位器生成

6.6 安装与配置

# 直接用 npx 启动(MCP 客户端会自动拉起,通常无需手动运行)
npx @playwright/mcp@latest

# 常用参数
--cdp-endpoint=http://127.0.0.1:9222   # 连接已打开的 Chrome(需非默认 --user-data-dir,见下)
--cdp-endpoint=chrome                  # 直接连日常 Chrome 的 channel(需在 chrome://inspect 勾选允许)
--headless / --browser chrome          # 无头模式 / 指定浏览器
--isolated                             # 内存中的临时 profile,不落盘
--user-data-dir <path>                 # 指定持久化 profile 目录
--caps=devtools,network,storage,vision # 开启额外能力组
--init-script <file.js>                # 每个页面加载前注入脚本(可用于打 hook)
--timeout-action 5000                  # 单动作超时,默认 5000ms
--port 8931                            # 改为 HTTP / SSE 传输(远程或多客户端共用)

本机 OpenCode 的实际配置(~/.config/opencode/opencode.json):

"mcp": {
  "playwright": {
    "type": "local",
    "command": ["npx", "-y", "@playwright/mcp@latest",
                "--cdp-endpoint=http://127.0.0.1:9222"],
    "enabled": true
  }
}

客户端配置的两种形态:type: local(默认,走 stdio 子进程),或远程 url: "http://localhost:8931/mcp"(走 HTTP)。多客户端共用时可加 --shared-browser-context。

6.6.1 连接现有 Chrome:Chrome 136+ 的硬限制

2025 年 3 月起(Chrome 136)--remote-debugging-port 默认 profile 不再生效。官方安全改动:这两个开关(含 --remote-debugging-pipe)在默认用户数据目录上被忽略,必须同时指定非默认的 --user-data-dir;否则端口根本不会监听,连上去只会得到 ECONNREFUSED。所以「先开日常 Chrome,再用 --cdp-endpoint=http://127.0.0.1:9222 连上去」这条老办法,在 Chrome 136+ 上默认走不通。目的是防止恶意程序借调试端口读取 Cookie——注意它保护的是「默认目录」,不是「Chrome 这个程序」。
你想达到的目标现在可行的做法代价 / 限制
复用日常 Chrome(登录态、扩展、已开标签页)在 Chrome 打开 chrome://inspect/#remote-debugging,勾选 Allow remote debugging for this browser instance;然后 MCP 用 --cdp-endpoint=chrome,CLI 用 playwright-cli attach --cdp=chrome每个浏览器实例手动勾选一次(重启 Chrome 后失效);Chrome 144+ 首次连接还会弹一次 Allow 授权框;被控期间顶部有「Chrome 正被自动化测试软件控制」横幅;且等于把整个浏览器(含 Cookie)交给自动化。未勾选时 CLI 会直接报 DevToolsActivePort file not found
复用日常 Chrome,但不开放调试端口安装 Playwright Extension(Chrome 应用商店),MCP 加 --extension,CLI 用 playwright-cli attach --extension=chrome需要装扩展;浏览器会有调试/控制提示;能力受扩展授权范围约束,但仍复用真实登录态,适合 SSO / 2FA / 依赖扩展的页面
无人值守 / CI / 稳定可重复独立 profile 起 Chrome:--remote-debugging-port=9222 --user-data-dir=~/.chrome-debug;或干脆用 Playwright 自带 Chromium(open)或 Chrome for Testing是另一个 profile,站点要重新登录一次;但没有弹窗、没有横幅、可脚本化,登录态可用 state-save / state-load 复用

另外两个变化:--cdp-endpoint 现在支持直接写 channel 名(chrome / chrome-canary / msedge …),由 Playwright 去读该 profile 下 Chrome 写出的 DevToolsActivePort 文件,不必自己拼端口号;playwright-cli list --all 会把本机可 attach 的 channel、profile 目录和推荐命令直接列出来,排查「连不上」时先跑它。

本机这条配置要特别注意:上文 opencode.json 里写的是 --cdp-endpoint=http://127.0.0.1:9222,它只在「已有进程监听 9222」时有效。如果平时并没有常驻这样一个调试实例,应改成 --cdp-endpoint=chrome(配合 chrome://inspect 勾选),否则 MCP 会在连接阶段直接失败。
与第 7 章的呼应:--timeout-action 默认 5000ms,正是第 7 章里「点击 5 秒超时」的时间来源;而 --init-script 可以让注入脚本在每次页面加载前自动执行,正好解决第 7 章提到的「location.reload() 后注入失效」问题。

6.7 局限与选型

本节列的是 MCP 侧的代价。需要澄清的是:这些代价并不是「浏览器自动化本身很贵」,而是「把页面状态一直放在模型上下文里」很贵。把状态挪到磁盘与守护进程,代价就换成另一种形式——6.8 讲 CLI + Skills 这条路,6.9 用同一个任务做实测对照,6.10 给选型判据。

6.8 playwright-cli:CLI + Skills 路线

playwright-cli 是微软为编程 Agent 单独提供的命令行入口(npm 包名 @playwright/cli)。注意它与 npx playwright test 不是一回事:后者是测试运行器,前者是给 Agent 用的浏览器遥控器——一条命令一个动作,浏览器由后台守护进程(daemon)持有。

设计目标一句话:让「浏览器状态」待在磁盘和守护进程里,只把「结论」放进模型上下文。

6.8.1 省 token 的三条机制

  1. 工具 schema 不进上下文。MCP 在会话开始时 tools/list,把 25+ 个工具的 JSON Schema 常驻在后续每一次请求里;对 CLI 而言,模型手里的「工具」只有 shell 一个,命令语法通过 Skill 按需注入,或让 Agent 自己读 --help 发现。
  2. 输出落盘,不落上下文。每次命令之后,CLI 回到模型眼里的只有这么多:
    ### Page
    - Page URL: https://demo.playwright.dev/todomvc/#/
    - Page Title: React • TodoMVC
    ### Snapshot
    [Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
    读不读、读多少、只 grep 哪一段,全部由 Agent 自己决定——「看页面」从强制动作变成按需动作。
  3. 过期状态不堆积。MCP 的每一棵历史无障碍树都留在上下文里,而且已经失效(stale);CLI 的快照是磁盘文件,下一次命令直接覆盖,上下文里不留垃圾。
官方演示基准(Playwright 团队的 CLI vs MCP 对比演示):同一个浏览器自动化任务,MCP 约 114,000 tokens,CLI 约 26,800 tokens,约 4 倍。轮次越多差距越大,长会话有报到 10 倍的。省下的不是「看页面」的开销,而是「看过的页面一直挂在上下文里」的开销。

6.8.2 安装与 Skills

# 安装 CLI(需要 Node.js 20+)
npm install -g @playwright/cli@latest
playwright-cli --help

# 安装 Skills:把命令用法注入编程 Agent
playwright-cli install --skills            # 装到 ~/.claude/skills
playwright-cli install --skills -g         # 装到全局(用户目录)
playwright-cli install --skills=agents -g  # 装到 ~/.agents/skills

Skill 的作用:把「有哪些命令、怎么组合」按需注入,避免 Agent 猜命令名、猜参数——猜错一次就浪费一个回合和一份输出。不装 Skill 也能跑(skills-less 操作):直接把 CLI 交给 Agent,并提示它读 playwright-cli --help 自行发现命令。

6.8.3 会话模型:状态在磁盘,不在上下文

「CLI 是无状态的」是常见误解。守护进程(daemon,常驻的辅助进程)持有浏览器,命令只是往它那儿发一次遥控:

playwright-cli open https://example.com --persistent   # profile 落盘,跨进程保留
playwright-cli -s=example open https://example.com     # 命名会话,多项目并行
playwright-cli list                                    # 列出所有会话
playwright-cli show                                    # 可视化面板:预览 + 接管鼠标键盘
playwright-cli close-all                               # 关闭全部会话
playwright-cli kill-all                                # 强制杀掉残留浏览器进程
PLAYWRIGHT_CLI_SESSION=todo-app claude .               # 让 Agent 绑定到指定会话
两种路线的一句话对照:MCP 把状态放在服务端进程 + 模型上下文;CLI 把状态放在守护进程 + 磁盘,上下文里只留命令与结论。
代价是:模型不「记得」页面结构,需要时得重新 snapshot 去读文件。这正是 MCP 在长链迭代推理里反而更省事、更稳的根因——它花 token 买了「记忆」。

6.8.4 命令速查

分类主要命令说明
打开与交互open [url]、goto、click <ref>、type <text>、fill <ref> <text>、select、check / uncheck、hover、drag、upload、close与 MCP 的核心自动化一一对应,都是「一个动作一条命令」
定位元素snapshot 取 ref → click e15也支持 CSS 选择器、role=button[name=Submit] 这类 role 选择器,以及 >> 链式定位
快照与导出snapshot [--filename=f]、screenshot [ref] [--hires]、pdf快照默认写文件并只回传路径;也可只快照某个元素
导航go-back、go-forward、reload—
键鼠press <key>、keydown / keyup、mousemove <x> <y>、mousedown / mouseup、mousewheel <dx> <dy>无障碍树覆盖不到的界面(Canvas 等)退回坐标操作
标签页tab-list、tab-new、tab-select、tab-close—
网络requests、request <num>、route / route-list / unroute相当于 MCP 的 --caps=network,用来 Mock 与抓请求
存储state-save / state-load、cookie-*、localstorage-*登录态复用
调试与录制console、eval、run-code、tracing-start / tracing-stop、video-start / video-chapter / video-stop相当于 MCP 的 --caps=devtools
WebMCPwebmcp-list、webmcp-call <name>页面通过实验性的 WebMCP API 自己注册工具给 Agent 调;工具名、描述、schema 与结果都由页面提供,属不可信输入,不能当指令执行

6.8.5 使用纪律(决定你到底省不省)

6.9 实战演示:同一个任务,MCP 与 CLI 各花多少上下文

演示任务(取自官方 walkthrough 的同一场景):打开 https://demo.playwright.dev/todomvc/ → 输入 "Buy groceries" 回车 → 输入 "Water flowers" 回车 → 勾选第一项 → 截图。总共 5~6 个动作,属于「小任务」。

6.9.1 MCP 路线:每步回传整棵树

在 OpenCode 这类 Host 里,实际发生的是:

步骤工具调用回到上下文的内容上下文增量
1browser_navigate动作后的完整无障碍树一整棵树
2browser_type "Buy groceries"又一次完整快照又一整棵树
3browser_press_key Enter又一次完整快照又一整棵树
4browser_type "Water flowers" + press_key Enter每次动作后各一份快照再两棵树
5browser_snapshot为拿到勾选框的 ref,再取一次全树又一整棵树
6browser_click e21动作后又一份快照又一整棵树
7browser_take_screenshot图像本身进入上下文图片 token(且官方明确它只能给人看,不能据此操作)

三点必须记住:

6.9.2 CLI 路线:每步只回一行路径

playwright-cli open https://demo.playwright.dev/todomvc/ --headed
playwright-cli type "Buy groceries"
playwright-cli press Enter
playwright-cli type "Water flowers"
playwright-cli press Enter
playwright-cli check e21
playwright-cli screenshot

每条命令回到上下文里的只有:动作是否成功 + 一行快照文件路径。命令里的 e21 也不必然需要先取一份全树——从最近一次快照文件里搜出这一行就够:

# 只需要一行 ref 时,不要整读快照文件
grep -n "checkbox" .playwright-cli/page-2026-02-14T19-22-42-679Z.yml

# 也可以直接要一份局部快照(限制深度 / 指定元素)
playwright-cli snapshot --depth=2
playwright-cli snapshot "#main"
实测对照(同一任务):MCP ≈ 114,000 tokens,CLI ≈ 26,800 tokens,约 1/4。差距的来源不是「少看了什么」,而是上下文里不再堆着历史快照。

6.9.3 两种路线的时序对照

PlantUML diagram
时序图——同一次「点击」,MCP 把结果内联进上下文,CLI 把结果落盘、只回传路径

6.9.4 逐维度对照

维度Playwright MCPplaywright-cli + Skills
上下文常驻25+ 工具的 JSON Schema,每次请求都带一份 Skill 说明(按需加载),或零(skills-less)
单次动作回传完整无障碍树,内联进上下文一行文件路径 + 页面 URL / Title
历史状态永久保留且已过期(stale),越积越多文件覆盖/删除,上下文里不留痕
模型「记忆力」强:页面结构就在上下文里,可直接多轮推理弱:需要时得重新读快照(但读多少自己定)
状态持久性Server 常驻进程,天然持久守护进程 + 命名会话 / --persistent,同样能持久
环境要求任何 MCP Host(含无 shell 的云沙箱)必须有 shell 与文件系统
可审计性调用记录留在会话里命令序列本身就是可复制的脚本
官方基准≈114k tokens≈26.8k tokens(≈4x 更省)
最适合探索式自动化、自愈测试、长时自治循环日常编程 Agent、批量重复、CI、上下文吃紧的长会话
结论:MCP 花 token 买的是记忆与内省(页面结构一直在上下文里、内省工具随叫随到);CLI 花的是每次重读(但读多少自己说了算)。同一个 Playwright 引擎,两种计价方式。
混合用法(推荐):探索阶段用 MCP 把路径摸出来 → 固化阶段改写成 playwright-cli 命令序列或 Library / Test 脚本。这与第 7 章「能抓接口就别点 UI」是同一条原则:把最贵的探索只做一次,之后不再让模型逐帧看页面。

6.10 选型决策

三条判据,按顺序问一遍即可:

  1. 宿主有没有 shell 与文件系统?没有(云沙箱、纯聊天界面)→ 只能 MCP。
  2. 上下文预算紧不紧?紧(长会话、大代码库)→ 优先 CLI。
  3. 需不需要「看一步、想一步」的迭代推理与长时自治?需要 → MCP。
PlantUML diagram
活动图——编程 Agent 的 Playwright 路线决策
你的情况选哪条理由
有 shell,任务路径已知CLI + Skills省掉 schema 与历史快照的常驻成本
大批量重复操作 / CI 里跑CLI,或直接 Library / TestN 次动作不该等于 N 份无障碍树
长会话、上下文已经吃紧CLI节省随轮次放大
云沙箱、无 shell 的宿主MCP没有别的选择
探索式:还不知道下一步点哪里MCP需要带着整棵页面树做多轮判断
自愈测试、长时自治循环MCP状态在服务端,中途不断链
需要反复查 console / network / traceMCP内省能力随叫随到,不必每次敲命令再看输出

两者底层是同一个引擎,不冲突:同时开会有两个独立的浏览器/会话,用命名会话(-s=)隔开即可。这就是官方「playwright-cli vs Playwright MCP」一节的全部结论——不是二选一,而是按场景分工。

7. 网页批量操作规范(实战)

一句话原则:能抓接口就别点 UI;必须点 UI 时,先保证「浏览器窗口在前台 + 页面帧率正常」(仅 attach 到你现有 Chrome 时;playwright-cli open 自建的 headless / headed 实例不需要,见 7.4),否则动作一定会超时。

本章的结论来自一次真实任务:把网盘里 38 个文件按「知名度」筛选后搬进归档目录(全部用移动、不删除)。整个过程里约 90% 的时间浪费在用鼠标点击上——反复超时、反复调定位;而最后 38 个文件的搬迁,只是一次接口调用就完成了。下面是可复用的规范。

7.1 动手前的决策树

任务需要同一种操作重复 3 次以上?
├─ 是 → 先找写接口(hook 抓一次真实请求)→ 之后全部走接口
└─ 否 → 走 UI 点击(但必须通过 6.4 的检查清单)

判断「有没有写接口」不要靠反复试参数(本次试了 6 组参数组合,全部返回 errno 2,纯属浪费),而要靠抓一次真实请求:让 UI 成功执行一次,把完整请求记下来。这一次点击是投资,不是浪费。

只读接口(列表、详情)几乎总能直接调用,先用它做库存盘点,不要靠 UI 数数。

7.2 抓接口套路:一次点击换 N 次点击

// ① 注入 hook(在页面内执行;注意 location.reload() 后会失效,必须重打)
window.__reqs = [];
const of = window.fetch;
window.fetch = function (...a) {
  window.__reqs.push({
    u: typeof a[0] === 'string' ? a[0] : a[0]?.url,
    b: a[1]?.body ? String(a[1].body).slice(0, 2000) : null,
    m: a[1]?.method || 'GET',
  });
  return of.apply(this, a);
};
const oo = XMLHttpRequest.prototype.open, os = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (m, u, ...r) { this.__u = m + ' ' + u; return oo.call(this, m, u, ...r); };
XMLHttpRequest.prototype.send = function (b) { window.__reqs.push({ u: this.__u, b: b ? String(b).slice(0, 2000) : null }); return os.call(this, b); };
// ② 用 UI 真实执行一次,然后读出请求
(window.__reqs || []).filter(r => /filemanager/.test(r.u || ''))
// 关键发现:filelist 必须是 [{"path":"...","dest":"...","newname":"..."}] 对象数组,
// 而不是路径字符串数组 —— 这才是之前 errno 2 的真正原因
// ③ 之后所有操作都在页面内 fetch,credentials:'include' 自动带 Cookie
await fetch('/api/filemanager?opera=move&bdstoken=...&async=2&onnest=fail&web=1', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: 'filelist=' + encodeURIComponent(JSON.stringify(items)),
});
要点说明
body 的结构才是成败关键URL 上的 bdstoken 只是身份参数,真正的参数格式在 body 里;抓到了就照抄,不要自己猜
批量提交一次 filelist 可以放 N 个对象,一次请求搬完一个目录(实测 23 个文件一把成功)
反爬层许多站点有 AntiRequest.getAntiParams() 一类的签名层;靠「抓一次真实请求」直接复用其参数,比逆向签名快得多
写操作后必校验用只读接口回读确认,不要相信返回值里的一行 errno:0

7.3 MCP 工具使用规范

事项规范原因
返回值解析包一层 helper,从 ### Result 与 ### Ran Playwright code 之间抽出 JSON每次手写正则很浪费时间
元素定位优先用 browser_evaluate 给目标节点注入稳定 id,再用 browser_click 按选择器点击避免依赖快照 ref;虚拟列表滚动后行会重渲染
快照少用 browser_snapshot体积大、慢,且会生成 yml 文件污染仓库
等待用 browser_wait_for({time:...}) 做短等SPA 无法靠网络空闲判断就绪
点击超时默认 5s(--timeout-action),不要指望调大,要消除「不可见 / 不稳定」的根因见 7.4

7.4 必须走 UI 时的硬性检查清单(缺一必失败)

先确认清单适用于谁。「必须保持窗口在前台」只对 attach 到你现有 Chrome(attach --cdp=chrome / 扩展 attach)成立——这个 Chrome 是你自己启动的,没有 Playwright 的防节流参数。用 playwright-cli open 自己启动的浏览器不需要:默认就是 headless(连窗口都没有),--headed 时 Playwright 也会带上 --disable-background-timer-throttling、--disable-backgrounding-occluded-windows、--disable-renderer-backgrounding,并开启焦点模拟(Emulation.setFocusEmulationEnabled)。实测切到别的 App 后 rAF 仍是满帧、document.hasFocus() 为 true。真正的分界是「复用你的浏览器」还是「Playwright 自己开的浏览器」。至于怎么 attach 上你的 Chrome(Chrome 136+ 已不允许默认 profile 开调试端口),见 6.6.1。
启动方式需要保前台 / 不遮挡?原因
open(默认 headless)否无窗口;帧循环照常跑,实测 62 FPS
open --headed / open --browser=chrome否自建实例带防节流参数,输入走 CDP(OS 焦点无关)
attach --cdp=chrome(需在 chrome://inspect 勾选允许)/ --extension是原样继承系统后台/遮挡节流,rAF 掉到约 2 FPS
原生对话框(文件选择 / 权限 / 打印 / 下载确认)是OS 层交互,Playwright 管不到;能绕则用 upload、grantPermissions、acceptDownloads
  1. (attach 模式)窗口必须在前台且未被遮挡 —— 第一杀手。
    osascript -e 'tell application "Google Chrome" to activate'
    # 然后测帧率:超过 30 帧 / 300ms 才算可用
    
    attach 上来的 Chrome 被切到后台或被遮挡时,requestAnimationFrame 会被节流到约 2 FPS,Playwright 的「元素稳定」检查永远不通过,动作必然 5 秒超时——这不是选择器写错,改选择器没用。自建浏览器不存在这个问题,别把 attach 的经验套到 headless 上。
  2. (attach 模式)先测帧率再动手,不要等超时了才发现窗口被切走。
  3. visibility:hidden 的元素点不到(典型如鼠标悬停才显形的复选框),注入样式强制可见:
    const st = document.createElement('style');
    st.textContent = 'label.hide-checkbox{visibility:visible!important;opacity:1!important}';
    document.head.appendChild(st);
    
  4. 虚拟滚动列表:DOM 里只保留屏幕内约 22 行,视口外的行根本不存在。可按序号滚动定位(scrollTop = idx * rowH - 400),滚动后必须重新取元素;更好的做法是直接改走接口。
  5. 不要点文件名单元格 —— 会打开在线预览并新建标签页;要点行首的复选框单元格 td:nth-child(1)。
  6. 每步之后校验状态(如 is-checked、表头「已选中 N 个文件/文件夹」),不要盲点。

7.5 元素定位规范

7.6 验收与反模式

双向校验:源目录减少 N 个 == 目标目录增加 N 个,不能只看一边。临时产生的中间对象(本次为搬运建的 _tmp_brian_probe 文件夹)必须显式删除并复核。

反模式问题正确做法
反复试接口参数组合试 6 次不如抓 1 次hook 抓一次真实请求后照抄
attach 的 Chrome 在后台时反复重试点击rAF 被节流到 2 FPS,必超时先置前并测帧率(自建浏览器无此问题)
用行文本 / 序号去重重复文本导致误取消勾选用接口返回的精确文件名
反复拉 browser_snapshot慢,且产生垃圾 yml 文件用 browser_evaluate 注入稳定 id
attach 模式下边自动化边用同一个浏览器做别的事抢焦点,动作必失败专用 profile;或干脆改走接口
在用户日常浏览器实例上跑自动化可能崩溃、污染用户会话(本次崩过一次:Google Chrome-2026-10-17-085355.ips)--user-data-dir=~/.chrome-debug --remote-debugging-port=9222

7.7 案例复盘:百度网盘批量归档

现象根因正确做法
写接口一直返回 errno 2filelist 应为对象数组,实际传了字符串数组抓一次真实请求照抄参数
点击 5 秒超时attach 的 Chrome 被切到后台,rAF 掉到 2 FPS置前 + 测帧率后再点(自建 headless 无此问题)
复选框点不到默认 visibility:hidden,悬停才显形注入 CSS 强制可见
滚动的行点不到虚拟列表只渲染屏内约 22 行按序号滚动后重新取元素,或改走接口
点 41 次只剩 1 个选中用行文本去重,重复文本导致二次点击用精确文件名定位
最终 38 个文件搬迁—抓到参数后,一次接口调用批量完成
经验公式:重复性任务的成本 ≈ 点击次数 × 单次失败率 × 重试成本。把点击次数压到 1(抓参数)或 0(走接口),比优化单次点击的写法有效得多。